Skip to content

feat: layout snapshot support (beta) - #21

Merged
kevinccbsg merged 1 commit into
mainfrom
feat/layout-snapshots
Sep 6, 2026
Merged

feat: layout snapshot support (beta)#21
kevinccbsg merged 1 commit into
mainfrom
feat/layout-snapshots

Conversation

@kevinccbsg

Copy link
Copy Markdown
Member

The twd-cli half of twd.matchLayout, which lands in twd-js 1.10.0.

matchLayout is off in the browser sidebar on purpose: the sidebar resizes the page and a developer's window is an arbitrary size, so a reference created there fails for everyone else. twd-cli is where a layout snapshot is actually decided, and this is what it needs to hold up that end.

Design: docs/superpowers/specs/2026-09-06-layout-snapshots-design.md.

Two flags, separate on purpose

npx twd-cli run --update-snapshots   # rewrite references that already exist
npx twd-cli run --ci                 # a missing reference is a failure, never created

Kept separate the way Jest separates them, because they close two different holes. Without --ci, a brand new test writes its own baseline on the first CI run and passes forever, and nobody finds out. That is the expensive one, because it makes no noise.

They go in via evaluateOnNewDocument, not evaluate, so they are set before any script on the page runs and matchLayout sees them on first read. The precedence between them is decided in twd-js, which is the only side that has fetched the reference; reimplementing it here would be a second copy of a rule, free to drift.

Behaviour change: the viewport

page.setViewport() now runs on every run, not just when recording. A normal run used to inherit Puppeteer's implicit size.

This is deliberate. Layout snapshots are only reproducible if the viewport is fixed and explicit, and relying on the implicit default would mean a Puppeteer upgrade could change it and invalidate every committed reference at once, without a word. Default is 1280x800, and viewport in twd.config.json pins your own. While recording, record.viewport still wins, so recording is untouched.

A test that happened to depend on the old implicit size can behave differently. That is the one thing here that can affect someone who never uses snapshots.

Seeing the failure

A failure writes <name>.failed.png next to the reference, but in CI that machine is gone by the time anyone looks. So the run also writes a self-contained .twd/snapshot-report.html with every capture embedded as a data URI. One file, one artifact, opens in any browser, instead of a zip of loose PNGs to match up by filename.

Captures from earlier runs are cleared first. twd-js overwrites a capture on failure but never removes one when that snapshot later passes, so without the sweep a fixed layout keeps its old capture forever and the report shows a failure that no longer exists. That is worse than having no report.

Deliberately not built

A 3 snapshots written, 1 updated summary line. It would have made it visible when --update-snapshots had been left on in a workflow and had quietly rewritten every reference, but that is a user error and this is a beta nobody has run in anger yet. Building a guard against a failure mode we have not seen is guessing.

Dropping it has a real payoff: twd-cli needs nothing from twd-js beyond the window flags it already reads. The report is built entirely from the PNGs on disk. Failures still surface normally, since matchLayout throws and the test fails with its message and a non-zero exit code.

Also out of scope: merging snapshot results across shards, and any GitHub Actions job summary integration (it would put noise on pull requests).

Testing

479 tests passing, up from 466.

  • tests/snapshotReport.test.js is new: 10 tests over real files in a tmpdir, covering the missing directory, no failures, two failures embedded, name derivation, an unreadable file skipped while the rest survive, an unwritable output directory, HTML escaping, and the stale sweep leaving .snap references alone.
  • tests/parseArgs.test.js and tests/config.test.js cover the flags and the two config keys, including that a partial viewport merges over the default rather than wiping the height.
  • tests/runTests.test.js pins the load-bearing detail: the injection happens before goto, asserted by invocation order, not by reading the code.

Adds the twd-cli half of twd.matchLayout, which lands in twd-js 1.10.0.
matchLayout is off in the browser sidebar on purpose, because the sidebar
resizes the page and a developer's window is an arbitrary size, so twd-cli is
where a layout snapshot is actually decided.

Two flags, kept separate the way Jest separates them, because they close two
different holes:

  --update-snapshots  rewrites references that already exist
  --ci                forbids creating one, so a brand new test cannot write
                      its own baseline on the first CI run and pass forever

They are injected with evaluateOnNewDocument rather than evaluate, so they are
set before any script on the page runs and matchLayout sees them on first read.

BEHAVIOUR CHANGE: page.setViewport now runs on every run, not just when
recording. A normal run used to inherit Puppeteer's implicit size. This is
deliberate: layout snapshots are only reproducible if the viewport is fixed and
explicit, and relying on the implicit default would mean a Puppeteer upgrade
could change it and invalidate every committed reference at once, silently. The
default is 1280x800 and `viewport` in twd.config.json pins your own. While
recording, record.viewport still wins, so recording is unchanged.

A failure writes <name>.failed.png next to the reference, but in CI that machine
is gone by the time anyone looks. So the run also writes a self-contained
.twd/snapshot-report.html with every capture embedded as a data URI: one file,
one artifact, opens in any browser. Captures from earlier runs are cleared
first, since twd-js overwrites a capture on failure but never removes one when
the snapshot later passes, and a stale capture showing an already-fixed failure
is worse than no report.

Deliberately not built: a "3 snapshots written, 1 updated" summary line. It
would have guarded against leaving --update-snapshots on in a workflow, but
that is a user error and this is a beta nobody has run in anger yet. Dropping it
also means twd-cli needs nothing from twd-js beyond the window flags: the report
is built entirely from the PNGs on disk. See section 8 of the design doc.
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

TWD Contract Validation

Spec Passed Failed Warnings Mode
./contracts/users-3.0.json 2 3 1 warn
./contracts/posts-3.1.json 2 2 0 warn
./contracts/products-3.0.json 13 23 2 warn
./contracts/events-3.1.json 6 13 0 warn

23 passed · 41 failed · 3 warnings · 1 skipped

Failed validations

./contracts/users-3.0.json

  • GET /users/{userId} (200) — mock getUserNoAddress — in "Contract Validation - Mismatches > should fail: missing nested address field"
    • response.address: missing required property "address"
  • GET /users/{userId} (200) — mock getUserBadAddress — in "Contract Validation - Mismatches > should fail: nested address missing required city"
    • response.address.city: missing required property "city"
    • response.address.country: missing required property "country"
  • GET /users/{userId} (200) — mock getUserBadRole — in "Contract Validation - Mismatches > should fail: oneOf role with invalid variant"
    • response.role: oneOf best match (branch 2 of 2) failed: must be one of: "viewer"

./contracts/posts-3.1.json

  • GET /posts/{postId} (200) — mock getPostNoAuthor — in "Contract Validation - Mismatches > should fail: post missing nested author object"
    • response.author: missing required property "author"
  • GET /posts/{postId} (200) — mock getPostBadMeta — in "Contract Validation - Mismatches > should fail: post oneOf metadata matches neither variant"
    • response.metadata: oneOf best match (branch 1 of 2) failed: missing required property "category", unexpected property "duration", must be one of: "article"

./contracts/products-3.0.json

  • GET /products (200) — mock getProductEmptyName — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: empty name violates minLength"
    • response[0].name: must NOT have fewer than 1 characters
  • GET /products (200) — mock getProductBadSku — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: invalid SKU pattern"
    • response[0].sku: must match pattern "^[A-Z]{2,4}-\d{4,8}$"
  • GET /products (200) — mock getProductBadUuid — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: invalid uuid format for id"
    • response[0].id: must match format "uuid"
  • GET /products (200) — mock getProductBadDateTime — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: invalid date-time format"
    • response[0].createdAt: must match format "date-time"
  • GET /products (200) — mock getProductBadDate — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: invalid date format"
    • response[0].releaseDate: must match format "date"
  • GET /products (200) — mock getProductBadEmail — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: invalid email format"
    • response[0].contactEmail: must match format "email"
  • GET /products (200) — mock getProductBadUri — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: invalid uri format"
    • response[0].website: must match format "uri"
  • GET /products (200) — mock getProductBadIp — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: invalid ipv4 format"
    • response[0].serverIp: must match format "ipv4"
  • GET /products (200) — mock getProductBadIpV6 — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: invalid ipv6 format"
    • response[0].serverIpV6: must match format "ipv6"
  • GET /products (200) — mock getProductZeroPrice — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: price of 0 violates exclusiveMinimum"
    • response[0].price: must be > 0
  • GET /products (200) — mock getProductNegQty — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: negative quantity violates minimum"
    • response[0].quantity: must be >= 0
  • GET /products (200) — mock getProductOverQty — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: quantity exceeds maximum"
    • response[0].quantity: must be <= 999999
  • GET /products (200) — mock getProductBadWeight — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: weight not multipleOf 0.01"
    • response[0].weight: must be multiple of 0.01
  • GET /products (200) — mock getProductBadRating — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: rating above maximum (5)"
    • response[0].rating: must be <= 5
  • GET /products (200) — mock getProductBadCurrency — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: invalid enum value for currency"
    • response[0].currency: must be one of: "USD", "EUR", "GBP", "JPY"
  • GET /products (200) — mock getProductBadCategory — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: invalid enum value for category"
    • response[0].category: must be one of: "electronics", "clothing", "food", "books", "toys"
  • GET /products (200) — mock getProductBadBool — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: string value for boolean inStock"
    • response[0].inStock: expected boolean, got string
  • GET /products (200) — mock getProductDupTags — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: duplicate tags violates uniqueItems"
    • response[0].tags: must NOT have duplicate items (items ## 1 and 0 are identical)
  • GET /products (200) — mock getProductTooManyTags — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: tags exceeds maxItems (10)"
    • response[0].tags: must NOT have more than 10 items
  • GET /products (200) — mock getProductBadMeta — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: non-string value in metadata additionalProperties"
    • response[0].metadata.count: expected string, got number
  • GET /settings (200) — mock getSettingsBadExtra — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: extra property on Settings (additionalProperties: false)"
    • response.extraField: unexpected property "extraField"
  • GET /settings (200) — mock getSettingsBadLang — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: invalid language pattern in Settings"
    • response.language: must match pattern "^[a-z]{2}(-[A-Z]{2})?$"
  • GET /products (200) — mock getProductBadNullable — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: wrong type for nullable description (number instead of string|null)"
    • response[0].description: expected string,null, got number

./contracts/events-3.1.json

  • GET /events (200) — mock getEventsEmpty — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: empty events array violates minItems (1)"
    • response: must NOT have fewer than 1 items
  • GET /events (200) — mock getEventShortName — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: event name too short (minLength: 3)"
    • response[0].name: must NOT have fewer than 3 characters
  • GET /events (200) — mock getEventBadDate — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: invalid date-time format for startDate"
    • response[0].startDate: must match format "date-time"
  • GET /events (200) — mock getEventFloatId — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: float value for integer id"
    • response[0].id: expected integer, got number
    • response[0].id: must match format "int64"
  • GET /events (200) — mock getEventBadBool — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: number value for boolean active"
    • response[0].active: expected boolean, got number
  • GET /events (200) — mock getEventBadStatus — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: invalid enum value for status"
    • response[0].status: must be one of: "draft", "published", "archived"
  • GET /events (200) — mock getEventScoreMax — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: score at exclusiveMaximum boundary (100)"
    • response[0].score: must be < 100
  • GET /events (200) — mock getEventLowPriority — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: priority below minimum (1)"
    • response[0].priority: must be >= 1
  • GET /events (200) — mock getEventHighPriority — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: priority above maximum (5)"
    • response[0].priority: must be <= 5
  • GET /events (200) — mock getEventDupAttendees — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: duplicate attendees violates uniqueItems"
    • response[0].attendees: must NOT have duplicate items (items ## 1 and 0 are identical)
  • GET /events (200) — mock getEventNoAttendees — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: empty attendees array violates minItems (1)"
    • response[0].attendees: must NOT have fewer than 1 items
  • GET /events (200) — mock getEventBadAttendee — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: invalid email format in attendees"
    • response[0].attendees[0]: must match format "email"
  • GET /events/{eventId} (200) — mock getEventBadNullable — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: wrong type for nullable description (number instead of string|null)"
    • response.description: expected string,null, got number

View full report →

@kevinccbsg
kevinccbsg merged commit a608f05 into main Sep 6, 2026
6 checks passed
This was referenced Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant